Skip to content

Stop SSE filter from leaking tools/list on undecodable lines#5304

Open
saivedant169 wants to merge 4 commits into
stacklok:mainfrom
saivedant169:fix/authz-sse-line-fallthrough
Open

Stop SSE filter from leaking tools/list on undecodable lines#5304
saivedant169 wants to merge 4 commits into
stacklok:mainfrom
saivedant169:fix/authz-sse-line-fallthrough

Conversation

@saivedant169
Copy link
Copy Markdown

Summary

ResponseFilteringWriter.processSSEResponse used to abort filtering of the entire SSE stream whenever a single data: line failed jsonrpc2.DecodeMessage or decoded to a non-*jsonrpc2.Response message. The fallback wrote the entire buffered upstream payload and returned, so any subsequent data: line containing the real tools/list reply reached the client unfiltered, bypassing the cedar authorization filter. The fallback also re-called WriteHeader after the reverse proxy's first Flush() had already emitted headers, producing the http: superfluous response.WriteHeader call from … response_filter.go:191 warning that surfaced the bug.

This PR:

  • Treats an undecodable or non-Response data: line as pass-through for that line only: fall through to the existing line writer (the loop's if !written branch) rather than dumping rawResponse and returning.
  • Drops the explicit WriteHeader(rfw.statusCode) calls in the fallback branches, which removes the double-header warning at line 191.
  • Emits a WARN log when a tools/list reply contains a data: line that bypasses the filter, so future bypasses are visible in audit logs instead of silent.

Notifications interleaved on a response stream are explicitly allowed by the MCP spec, so this case can be hit by fully spec-compliant upstreams (notifications/message, progress updates, etc.) as well as by upstreams fronted by an SSE bridge (the npm mcp-proxy case from the issue reproduction).

Closes #5257

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Added TestResponseFilteringWriter_SSE_PerLineFallthrough as a table-driven regression test covering both branches (decode error and non-Response decode). It builds an SSE stream that interleaves a notification (or an undecodable garbage line) with a real tools/list response, then asserts:

  1. The preceding line is preserved verbatim on the wire.
  2. The tools/list response is filtered to only authorized tools.
  3. The literal string "admin_tool" (the unauthorized tool from the unfiltered upstream payload) does not appear anywhere in the output.

The test fails on the prior code with admin_tool reaching the recorder, and passes on the patched code.

Verified on pkg/authz/... with -race:

  • go test -ldflags=-extldflags=-Wl,-w -race ./pkg/authz/... passes (including the existing JSON-path tests and the new SSE test).
  • golangci-lint run ./pkg/authz/... reports 0 issues.

Does this introduce a user-facing change?

Yes. On SSE upstreams (Content-Type: text/event-stream), tools/list, prompts/list, and resources/list responses now respect the configured cedar authorization filter even when the upstream interleaves notifications or sends an undecodable data: line. Previously such streams returned the unfiltered tool/prompt/resource catalog to the caller. Filtered behavior is what operators already get on application/json upstreams; this brings SSE in line.

Special notes for reviewers

The change is intentionally minimal. I considered also auditing processJSONResponse for the same shape (return rawResponse on per-line failure) but it processes a single message, not a stream, so the same code shape there is already correct. Happy to extend if you want a defensive log added on that side too.

processSSEResponse used to write the entire raw upstream payload and
return whenever a single SSE data line failed jsonrpc2.DecodeMessage or
decoded to a non-Response message (e.g. a notifications/* frame). On a
tools/list reply that meant every subsequent data line, including the
real Response, reached the client unfiltered, silently bypassing the
cedar authorization filter and producing the superfluous WriteHeader
warning at response_filter.go:191.

Treat undecodable or non-Response data lines as pass-through for that
line only: fall through to the existing line writer instead of dumping
rawResponse. The explicit WriteHeader calls go away with them, which
also removes the double-header warning that surfaced the bug. Skipped
filtering on tools/list now emits a WARN so future bypasses are
visible in audit logs.

Adds a table-driven regression test covering both branches (decode
error and non-Response). It fails on the old code with the unfiltered
admin_tool entry reaching the recorder.

Closes stacklok#5257
@jhrozek
Copy link
Copy Markdown
Contributor

jhrozek commented May 18, 2026

This PR looks like it's targetting issue #5292 ?

@jhrozek
Copy link
Copy Markdown
Contributor

jhrozek commented May 18, 2026

cc @amirejaz

@amirejaz
Copy link
Copy Markdown
Contributor

Not quite — #5304 closes #5257, which is an authorization bypass in the Cedar SSE response filter (pkg/authz/response_filter.go): when a data: line fails to decode as a *jsonrpc2.Response (e.g. an interleaved notification), the filter was dumping the entire raw unfiltered upstream payload, bypassing Cedar entirely.

Our issue #5292 is a separate concern in a different layer: per-event JSON-RPC frame validation in the transparent proxy (pkg/transport/proxy/transparent/), analogous to what #5288 did for streamable-HTTP. #5292 is about the proxy rejecting/closing-stream on structurally malformed frames, not about the Cedar filter.

The two are complementary — #5304 fixes a more serious auth bypass, #5292 is about frame validation in the non-auth transparent proxy path.

Copy link
Copy Markdown
Contributor

@yrobla yrobla left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The direction is correct and the core fix is sound — removing the whole-payload dump and letting the existing if !written branch handle per-line passthrough is exactly right. Two warnings below about logging coverage and test coverage.

Comment thread pkg/authz/response_filter.go Outdated
// Pass this line through unfiltered. Earlier revisions wrote
// rawResponse and returned here, which leaked every subsequent
// data line on the stream past the filter (issue #5257).
if rfw.method == string(mcp.MethodToolsList) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: WARN logs are only emitted for tools/list, but the bypass applies to all four filtered methods.

Both log conditions — err != nil branch here, and the else if for non-*jsonrpc2.Response in the default: branch below — are gated on rfw.method == string(mcp.MethodToolsList). However, requiresResponseFiltering covers four methods: tools/list, prompts/list, resources/list, and find_tool. An SSE backend serving prompts/list or resources/list with interleaved MCP notifications would silently hit the same passthrough path — no log line at all.

The security fix itself is correct for all methods (the written flag controls passthrough regardless of method), but the observability goal from the issue — "future bypasses surface in audit logs rather than going silent" — is only fulfilled for tools/list.

Consider replacing both method checks with requiresResponseFiltering(rfw.method), which already encodes the right set.

Comment thread pkg/authz/response_filter_test.go Outdated
// an MCP notification) or an undecodable data line with a real tools/list
// response, the filter previously wrote the entire raw upstream payload and
// returned, leaking the unfiltered tools/list past Cedar. It must instead pass
// only the offending line through and continue filtering the rest of the
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: Regression test only covers MethodToolsList; the same bypass applies to prompts/list and resources/list over SSE.

The new test hardcodes string(mcp.MethodToolsList). The same code path in processSSEResponse runs for any method where requiresResponseFiltering is true — including MethodPromptsList and MethodResourcesList. A notification-interleaved SSE response on those paths is not currently exercised.

A brief additional sub-test for MethodPromptsList or MethodResourcesList with an interleaved notification would confirm parity and guard against a future regression on those paths.

Address review feedback on stacklok#5304:

The WARN log paths in processSSEResponse were gated on the
tools/list method, but the underlying bypass applies to every
method routed through requiresResponseFiltering (tools/list,
prompts/list, resources/list, find_tool). Drop the gating so
the WARN fires for any of them.

The regression test only exercised tools/list. Restructure it
into a method-by-preceding-line table so the same notification
and undecodable line cases run against prompts/list and
resources/list as well. Verified the WARN now logs for all
three methods and the unauthorized entry never reaches the
recorder.
@saivedant169
Copy link
Copy Markdown
Author

Both points addressed in 3984262.

The WARN logs no longer gate on tools/list, so any method that hits processSSEResponse via requiresResponseFiltering (tools/list, prompts/list, resources/list, find_tool) now WARNs when a data line is undecodable or non-Response.

The regression test is now a method-by-preceding-line table over all three list methods. Each method has its own cedar policy and a one-authorized + one-unauthorized fixture; both the notification and undecodable-line cases run against tools/list, prompts/list, and resources/list. Confirmed the unauthorized entry never reaches the recorder for any of the six combinations.

@yrobla ready for another look when you have a moment.

@amirejaz amirejaz requested a review from yrobla May 20, 2026 15:42
@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label May 20, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

❌ Patch coverage is 68.42105% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.40%. Comparing base (bd73817) to head (8d39fde).

Files with missing lines Patch % Lines
pkg/authz/response_filter.go 68.42% 3 Missing and 3 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #5304   +/-   ##
=======================================
  Coverage   68.40%   68.40%           
=======================================
  Files         624      624           
  Lines       63442    63442           
=======================================
+ Hits        43397    43400    +3     
+ Misses      16807    16805    -2     
+ Partials     3238     3237    -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

authz: SSE response filter leaks unfiltered tools/list on undecodable / non-Response data lines (bypasses cedar)

4 participants